/*
Copyright 2023 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { checkSessionLockFree, getSessionLock, SESSION_LOCK_CONSTANTS } from "../../src/utils/SessionLock";
import { resetJsDomAfterEach } from "../test-utils";

describe("SessionLock", () => {
    const otherWindows: Array<Window> = [];

    beforeEach(() => {
        jest.useFakeTimers({ now: 1000 });
    });

    afterEach(() => {
        // shut down other windows created by `createWindow`
        otherWindows.forEach((window) => window.close());
        otherWindows.splice(0);
    });

    resetJsDomAfterEach();

    it("A single instance starts up normally", async () => {
        const onNewInstance = jest.fn();
        const result = await getSessionLock(onNewInstance);
        expect(result).toBe(true);
        expect(onNewInstance).not.toHaveBeenCalled();
    });

    it("A second instance starts up normally when the first shut down cleanly", async () => {
        // first instance starts...
        const onNewInstance1 = jest.fn();
        expect(await getSessionLock(onNewInstance1)).toBe(true);
        expect(onNewInstance1).not.toHaveBeenCalled();

        // ... and navigates away
        window.dispatchEvent(new Event("pagehide", {}));

        // second instance starts as normal
        expect(checkSessionLockFree()).toBe(true);
        const onNewInstance2 = jest.fn();
        expect(await getSessionLock(onNewInstance2)).toBe(true);

        expect(onNewInstance1).not.toHaveBeenCalled();
        expect(onNewInstance2).not.toHaveBeenCalled();
    });

    it("A second instance starts up *eventually* when the first terminated uncleanly", async () => {
        // first instance starts...
        const onNewInstance1 = jest.fn();
        expect(await getSessionLock(onNewInstance1)).toBe(true);
        expect(onNewInstance1).not.toHaveBeenCalled();
        expect(checkSessionLockFree()).toBe(false);

        // and pings the timer after 5 seconds
        jest.advanceTimersByTime(5000);
        expect(checkSessionLockFree()).toBe(false);

        // oops, now it dies. We simulate this by forcibly clearing the timers.
        // For some reason `jest.clearAllTimers` also resets the simulated time, so preserve that
        const time = Date.now();
        jest.clearAllTimers();
        jest.setSystemTime(time);
        expect(checkSessionLockFree()).toBe(false);

        // time advances a bit more
        jest.advanceTimersByTime(5000);
        expect(checkSessionLockFree()).toBe(false);

        // second instance tries to start. This should block for 25 more seconds
        const onNewInstance2 = jest.fn();
        let session2Result: boolean | undefined;
        getSessionLock(onNewInstance2).then((res) => {
            session2Result = res;
        });

        // after another 24.5 seconds, we are still waiting
        jest.advanceTimersByTime(24500);
        expect(session2Result).toBe(undefined);
        expect(checkSessionLockFree()).toBe(false);

        // another 500ms and we get the lock
        await jest.advanceTimersByTimeAsync(500);
        expect(session2Result).toBe(true);
        expect(checkSessionLockFree()).toBe(false); // still false, because the new session has claimed it

        expect(onNewInstance1).not.toHaveBeenCalled();
        expect(onNewInstance2).not.toHaveBeenCalled();
    });

    it("A second instance waits for the first to shut down", async () => {
        // first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
        await getSessionLock(
            () =>
                new Promise<void>((resolve) => {
                    setTimeout(resolve, 2000, 0);
                }),
        );

        // second instance tries to start, but should block
        const { window: window2, getSessionLock: getSessionLock2 } = buildNewContext();
        let session2Result: boolean | undefined;
        getSessionLock2(async () => {}).then((res) => {
            session2Result = res;
        });
        await jest.advanceTimersByTimeAsync(100);
        // should still be blocking
        expect(session2Result).toBe(undefined);

        await jest.advanceTimersByTimeAsync(2000);
        await jest.advanceTimersByTimeAsync(0);

        // session 2 now gets the lock
        expect(session2Result).toBe(true);
        window2.close();
    });

    it("If a third instance starts while we are waiting, we give up immediately", async () => {
        // first instance starts. It will never release the lock.
        await getSessionLock(() => new Promise(() => {}));

        // first instance should ping the timer after 5 seconds
        jest.advanceTimersByTime(5000);

        // second instance starts
        const { getSessionLock: getSessionLock2 } = buildNewContext();
        let session2Result: boolean | undefined;
        const onNewInstance2 = jest.fn();
        getSessionLock2(onNewInstance2).then((res) => {
            session2Result = res;
        });

        await jest.advanceTimersByTimeAsync(100);
        // should still be blocking
        expect(session2Result).toBe(undefined);

        // third instance starts
        const { getSessionLock: getSessionLock3 } = buildNewContext();
        getSessionLock3(async () => {});
        await jest.advanceTimersByTimeAsync(0);

        // session 2 should have given up
        expect(session2Result).toBe(false);
        expect(onNewInstance2).toHaveBeenCalled();
    });

    it("If two new instances start concurrently, only one wins", async () => {
        // first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
        await getSessionLock(async () => {
            await new Promise<void>((resolve) => {
                setTimeout(resolve, 2000, 0);
            });
        });

        // first instance should ping the timer after 5 seconds
        jest.advanceTimersByTime(5000);

        // two new instances start at once
        const { getSessionLock: getSessionLock2 } = buildNewContext();
        let session2Result: boolean | undefined;
        getSessionLock2(async () => {}).then((res) => {
            session2Result = res;
        });

        const { getSessionLock: getSessionLock3 } = buildNewContext();
        let session3Result: boolean | undefined;
        getSessionLock3(async () => {}).then((res) => {
            session3Result = res;
        });

        await jest.advanceTimersByTimeAsync(100);
        // session 3 still be blocking. Session 2 should have given up.
        expect(session2Result).toBe(false);
        expect(session3Result).toBe(undefined);

        await jest.advanceTimersByTimeAsync(2000);
        await jest.advanceTimersByTimeAsync(0);

        // session 3 now gets the lock
        expect(session2Result).toBe(false);
        expect(session3Result).toBe(true);
    });

    /** build a new Window in the same domain as the current one.
     *
     * We do this by constructing an iframe, which gets its own Window object.
     */
    function createWindow() {
        const iframe = window.document.createElement("iframe");
        window.document.body.appendChild(iframe);
        const window2: any = iframe.contentWindow;

        otherWindows.push(window2);

        // make the new Window use the same jest fake timers as us
        for (const m of ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"]) {
            // @ts-ignore
            window2[m] = global[m];
        }
        return window2;
    }

    /**
     * Instantiate `getSessionLock` in a new context (ie, using a different global `window`).
     *
     * The new window will share the same fake timer impl as the current context.
     *
     * @returns the new window and (a wrapper for) getSessionLock in the new context.
     */
    function buildNewContext(): {
        window: Window;
        getSessionLock: (onNewInstance: () => Promise<void>) => Promise<boolean>;
    } {
        const window2 = createWindow();

        // import the dependencies of getSessionLock into the new context
        window2._uuid = require("uuid");
        window2._logger = require("matrix-js-sdk/src/logger");
        window2.SESSION_LOCK_CONSTANTS = SESSION_LOCK_CONSTANTS;

        // now, define getSessionLock as a global
        window2.eval(String(getSessionLock));

        // return a function that will call it
        function callGetSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
            // import the callback into the context
            window2._getSessionLockCallback = onNewInstance;

            // start the function
            try {
                return window2.eval(`getSessionLock(_getSessionLockCallback)`);
            } finally {
                // we can now clear the callback
                delete window2._getSessionLockCallback;
            }
        }

        return { window: window2, getSessionLock: callGetSessionLock };
    }
});
